Projet P5 - Segmentez des clients d'un site e-commerce¶

OPENCLASSROOMS - Parcours Data Scientist - Adeline Le Ray - 03/2024


logo_olist.png

Introduction¶

Segmentation des clients : L'objectif est de comprendre les différents types d’utilisateurs grâce à leur comportement et à leurs données personnelles. La segmentation proposée doit être exploitable et facile d’utilisation par l'équipe Marketing. Elle doit au minimum pouvoir différencier les bons et moins bons clients en termes de commandes et de satisfaction.

Les méthodes utilisées sont :

  1. RFM marketing
  2. Méthodes non supervisées :
    • K-means
    • Classification Ascendante Hiérarchique
    • DBScan

Essais :

  • Variables RFM : déterminer la méthode la plus efficace
  • Variables RFM + satisfaction
  • Plus de variables : ACP + clustering

Sommaire¶

Notebook 1 - Requêtes SQL

Notebook 2 - Analyse exploratoire

Notebook 3 - Essais clustering

Partie 1 - RFM Marketing

  • Définition du RFM score
  • Segmentation RFM Marketing
  • Analyse des segments

Partie 2 - RFM avec méthodes d'apprentissage non supervisé

  • K-Means
  • Classification Ascendante Hierarchique
  • DBScan

Partie 3 - Clustering avec les variables RFM et satisfaction clients

  • Standardisation des donnes
  • Nombre optimal de clusters
  • Clustering
  • Analyse des clusters

Partie 4 - Analyse en Composantes Principales puis Clustering

  • Réduction de dimensions PCA
  • Clustering k-means
  • Analyse des clusters

Conclusion & Perspectives

Notebook 4 - Simulation maintenance

Importation des librairies et des données¶

In [1]:
import numpy as np
import pandas as pd
import math

# graphiques
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import seaborn as sns
import plotly.express as px
import plotly.graph_objects as go

import os
from joblib import Parallel, delayed

from IPython.display import Markdown # affichage Markdown des Outputs

# Principal Composante Analysis
from sklearn.decomposition import PCA

from sklearn.preprocessing import StandardScaler

# Classification Ascendante Hierarchique
from scipy.cluster.hierarchy import dendrogram, linkage, fcluster

# K-Means / DBScan
from sklearn.cluster import KMeans, DBSCAN
from sklearn.neighbors import NearestNeighbors
from sklearn.metrics import silhouette_score
from yellowbrick.cluster import SilhouetteVisualizer
from yellowbrick.cluster import KElbowVisualizer
In [2]:
# Version python
!python --version
# Version des librairies utilisées
print('\n'.join(f'{m.__name__} - {m.__version__}'
                for m in globals().values()
                if getattr(m, '__version__', None)))
Python 3.11.4
numpy - 1.26.4
pandas - 2.1.1
seaborn - 0.13.0
In [3]:
# Paramètres par défauts des graphiques
sns.set_style('whitegrid')        # darkgrid, white grid, dark, white and ticks
plt.rc('axes', titlesize=15)     # fontsize of the axes title
plt.rc('axes', labelsize=14)     # fontsize of the x and y labels
plt.rc('xtick', labelsize=13)    # fontsize of the tick labels
plt.rc('ytick', labelsize=13)    # fontsize of the tick labels
plt.rc('legend', fontsize=13)    # legend fontsize
plt.rc('font', size=13)          # controls default text sizes
width = 7
height = 5
plt.figure(figsize=(width, height))
meanprops = {'marker':'o', 'markeredgecolor':'black','markerfacecolor':'firebrick'}
<Figure size 700x500 with 0 Axes>
In [4]:
# Options d'affichage : toutes les colonnes
pd.set_option('display.max_columns', None)
In [5]:
os.environ["OMP_NUM_THREADS"] = '1'
In [6]:
# Définition de la palette de couleur
my_palette =  px.colors.qualitative.Plotly
In [7]:
df_cleaned = pd.read_pickle('df_cleaned.pkl')
df_cleaned.set_index(df_cleaned.columns[0], inplace=True)

Définition des fonctions¶

In [8]:
# Fonction pour générer un radar plot par ligne du dataframe
def make_spider(data, group, row, title, color):
    """!
    @brief Génère un radar plot pour une ligne d'un dataframe.

    Cette fonction permet de générer un radar plot pour une ligne d'un dataframe (exemple : centroïdes de clusters).

    @param data: Dataframe contenant les données (pandas DataFrame).
    @param group: Variable contenant l'intitulé du groupe (type string)
    @param row : ligne du dataframe (type integer)
    @param title : titre du radar plot (type string)
    @param color : couleur du radar plot (type string)
    """

     # Nombre de variables
    categories = list(data)[1:]
    N = len(categories)

    # Angle de chaque axe dans le plot (nous divisons le plot / nombre de variables)
    angles = [n / float(N) * 2 * math.pi for n in range(N)]
    angles += angles[:1]

    # Définir le nombre de sous-tracés
    if len(data.index) % 2 != 0:
        line = round((len(data.index) + 1) / 2)
    else:
        line = round(len(data.index) / 2)

    # Initialiser le radar plot
    ax = plt.subplot(line, 2, row + 1, polar=True)

    # Si vous voulez que le premier axe soit en haut :
    ax.set_theta_offset(math.pi / 2)
    ax.set_theta_direction(-1)

    # Dessiner un axe par variable + ajouter les libellés
    plt.xticks(angles[:-1], categories, color='grey', size=8)


    # Dessiner les libellés y
    ax.set_rlabel_position(0)

    # Echelle du tracé
    ylim_min = data.select_dtypes(include='number').min().min()
    ylim_max = data.select_dtypes(include='number').max().max()
    plt.ylim(ylim_min,ylim_max)

    # Valeurs de la ligne spécifiée
    values = data.loc[row].drop(group).values.flatten().tolist()
    values += values[:1]
    ax.plot(angles, values, color=color, linewidth=2, linestyle='solid')
    ax.fill(angles, values, color=color, alpha=0.4)

    # Ajouter un titre
    plt.title(title, size=11, color=color, y=1.1)

    plt.tight_layout()
In [9]:
def correlation_graph(pca,
                      x_y,
                      features) :
    """!
    @brief Affiche le graphe des correlations

    Positional arguments :
    -----------------------------------
    @param pca : sklearn.decomposition.PCA : notre objet PCA qui a été fit
    @param x_y : list ou tuple : le couple x,y des plans à afficher, exemple [0,1] pour F1, F2
    @param features : list ou tuple : la liste des features (ie des dimensions) à représenter
    """

    # Extrait x et y
    x,y=x_y

    # Taille de l'image (en inches)
    fig, ax = plt.subplots(figsize=(15, 11))

    # Pour chaque composante :
    for i in range(0, pca.components_.shape[1]):

        # Les flèches
        ax.arrow(0,0,
                pca.components_[x, i],
                pca.components_[y, i],
                head_width=0.07,
                head_length=0.07,
                width=0.02, )

        # Les labels
        plt.text(pca.components_[x, i] + 0.05,
                pca.components_[y, i] + 0.05,
                features[i])

    # Affichage des lignes horizontales et verticales
    plt.plot([-1, 1], [0, 0], color='grey', ls='--')
    plt.plot([0, 0], [-1, 1], color='grey', ls='--')

    # Nom des axes, avec le pourcentage d'inertie expliqué
    plt.xlabel('PC{} ({}%)'.format(x+1, round(100*pca.explained_variance_ratio_[x],1)))
    plt.ylabel('PC{} ({}%)'.format(y+1, round(100*pca.explained_variance_ratio_[y],1)))

    plt.title("Cercle des corrélations (PC{} et PC{})".format(x+1, y+1))

    # Le cercle
    an = np.linspace(0, 2 * np.pi, 100)
    plt.plot(np.cos(an), np.sin(an))  # Add a unit circle for scale

    # Axes et display
    plt.axis('equal')
    plt.show(block=False)
In [10]:
def display_factorial_planes(   X_projected,
                                x_y,
                                pca=None,
                                labels = None,
                                clusters=None,
                                alpha=1,
                                figsize=[10,8],
                                marker="." ):
    """!
    @brief Affiche la projection des individus

    Positional arguments :
    -------------------------------------
    @param X_projected : np.array, pd.DataFrame, list of list : la matrice des points projetés
    @param x_y : list ou tuple : le couple x,y des plans à afficher, exemple [0,1] pour PC1, PC2

    Optional arguments :
    -------------------------------------
    @param pca : sklearn.decomposition.PCA : un objet PCA qui a été fit, cela nous permettra d'afficher la variance de chaque composante, default = None
    @param labels : list ou tuple : les labels des individus à projeter, default = None
    @param clusters : list ou tuple : la liste des clusters auquel appartient chaque individu, default = None
    @param alpha : float in [0,1] : paramètre de transparence, 0=100% transparent, 1=0% transparent, default = 1
    @param figsize : list ou tuple : couple width, height qui définit la taille de la figure en inches, default = [10,8]
    @param marker : str : le type de marker utilisé pour représenter les individus, points croix etc etc, default = "."
    """

    # Transforme X_projected en np.array
    X_ = np.array(X_projected)

    # Forme de la figure
    if not figsize:
        figsize = (7,6)

    # Labels
    if  labels is None :
        labels = []
    try :
        len(labels)
    except Exception as e :
        raise e

    # Vérification de la variable axis
    if not len(x_y) ==2 :
        raise AttributeError("2 axes sont demandées")
    if max(x_y )>= X_.shape[1] :
        raise AttributeError("la variable axis n'est pas bonne")

    # Définition de x et y
    x, y = x_y

    # Initialisation de la figure
    fig, ax = plt.subplots(1, 1, figsize=figsize)

    # Vérification de la présence de clustrers ou non
    c = None if clusters is None else clusters

    # Les points
    # plt.scatter(   X_[:, x], X_[:, y], alpha=alpha,
    #                     c=c, cmap="Set1", marker=marker)
    sns.scatterplot(data=None, x=X_[:, x], y=X_[:, y], hue=c)

    # Si la variable pca a été fournie, on peut calculer le % de variance de chaque axe
    if pca :
        v1 = str(round(100*pca.explained_variance_ratio_[x]))  + " %"
        v2 = str(round(100*pca.explained_variance_ratio_[y]))  + " %"
    else :
        v1=v2= ''

    # Nom des axes, avec le pourcentage d'inertie expliqué
    ax.set_xlabel(f'PC{x+1} {v1}')
    ax.set_ylabel(f'PC{y+1} {v2}')

    # Valeur x max et y max
    x_max = np.abs(X_[:, x]).max() *1.1
    y_max = np.abs(X_[:, y]).max() *1.1

    # Bornes pour x et y
    ax.set_xlim(left=-x_max, right=x_max)
    ax.set_ylim(bottom= -y_max, top=y_max)

    # Affichage des lignes horizontales et verticales
    plt.plot([-x_max, x_max], [0, 0], color='grey', alpha=0.8)
    plt.plot([0,0], [-y_max, y_max], color='grey', alpha=0.8)

    # Affichage des labels des points
    if len(labels) :
        for i,(_x,_y) in enumerate(X_[:,[x,y]]):
            plt.text(_x, _y+0.05, labels[i], fontsize='14', ha='center',va='center')

    # Titre et display
    plt.title(f"Projection des individus (sur PC{x+1} et PC{y+1})")
    plt.show()

Partie 1 - RFM Marketing¶

La segmentation RFM prend en compte les variables suivantes pour établir des segments de clients homogènes :

  • la Récence (date de la dernière commande),
  • la Fréquence des commandes
  • le Montant (de la dernière commande ou sur une période donnée)
In [11]:
# Dataframe RFM
rfm_df = df_cleaned[['recency', 'frequency', 'monetary']]
In [12]:
# Centrage et réduction des variables
X_rfm = rfm_df.values # Matrice des données
names = rfm_df.index # Noms des individus
features_rfm = ['recency','frequency','monetary'] # Noms des variables

scaler = StandardScaler() # Instanciation du scaler
X_rfm_scaled = scaler.fit_transform(X_rfm) # Données scalées

Définition du RFM score¶

Pour la fréquence des achats, comme seuls 3% des clients ont effectué plus de 1 achat, la discétisation du score frequency ne se fera pas sur la base des quartiles. Les classes seront définies manuellement.

In [13]:
# Nombre de clients par fréquence d'achats
rfm_df.groupby('frequency', as_index=False)['recency'].count()
Out[13]:
frequency recency
0 1 90254
1 2 2562
2 3 180
3 4 27
4 5 9
5 6 5
6 7 3
7 9 1
8 15 1
In [14]:
# RFM score
rfm_df = rfm_df.copy()

rfm_df['R'] = pd.qcut(rfm_df['recency'], 5, labels=[5, 4, 3, 2, 1])
rfm_df['F'] = pd.cut(rfm_df['frequency'], [1, 2, 3, 4, 6, 16], labels=[1, 2, 3, 4, 5], right=False)
rfm_df['M'] = pd.qcut(rfm_df['monetary'], 5, labels=[1, 2, 3, 4, 5])
rfm_df['RFM_Score'] = rfm_df['R'].astype(str) + rfm_df['F'].astype(str) + rfm_df['M'].astype(str)
In [15]:
# Tailles des différentes classes
rfm_df.loc[:,['R','F','M']] = rfm_df[['R','F','M']].astype(int)

size_R = rfm_df.groupby('R', as_index=False,observed=True).size().rename(columns={'size':'size_R'}).sort_values('R')
size_F = rfm_df.groupby('F', as_index=False,observed=True).size().rename(columns={'size':'size_F'}).sort_values('F')
size_M = rfm_df.groupby('M', as_index=False,observed=True).size().rename(columns={'size':'size_M'}).sort_values('M')

size_rfm = pd.DataFrame({'class' : size_R['R'],
                         'size_R':size_R['size_R'],
                         'size_F':size_F['size_F'],
                         'size_M':size_M['size_M']}
                         )

display(size_rfm)
class size_R size_F size_M
0 5 18630 90254 18615
1 4 18643 2562 18602
2 3 18642 180 18608
3 2 18617 36 18612
4 1 18510 10 18605

Segmentation RFM Marketing¶

In [16]:
# Dictionnaire pour la correspondance des segments basés sur recency et frequency
seg_map = {
    r'[1-2][1-2]': 'Hibernating',
    r'[1-2][3-5]': 'At Risk',
    r'[3][1-3]': 'Need Attention',
    r'[4-5][1-3]': 'Potential Loyalists',
    r'[3-4][4-5]': 'Loyal Customers',
    r'5[4-5]': 'Champions'
}
In [17]:
# Attribution des segments
rfm_df.loc[:, 'Segment'] = rfm_df['R'].astype(str) + rfm_df['F'].astype(str)
rfm_df.loc[:,'Segment'] = rfm_df['Segment'].replace(seg_map, regex=True)
rfm_df.head()
Out[17]:
recency frequency monetary R F M RFM_Score Segment
customer_unique_id
0000366f3b9a7992bf8c76cfdf3221e2 160.0 1 141.90 4 1 4 414 Potential Loyalists
0000b849f77a49e4a4ce2b2a4ca5be3f 163.0 1 27.19 4 1 1 411 Potential Loyalists
0000f46a3911fa3c0805444483337064 586.0 1 86.22 1 1 2 112 Hibernating
0000f6ccb0745a6a4b88665a16c9f078 370.0 1 43.62 2 1 1 211 Hibernating
0004aac84e0df4da2b147fca70cf8255 337.0 1 196.89 2 1 4 214 Hibernating
In [18]:
# Caractéristiques moyennes de chaque segment
centroids_rfm = rfm_df.groupby('Segment')[['recency','frequency','monetary']].mean().sort_values('monetary')

Analyse des segments¶

In [19]:
# Nombre de clients par segment RFM
plt.figure(figsize=(10,5))
segment_counts = rfm_df['Segment'].value_counts()

sns.barplot(x=segment_counts.index, y=segment_counts.values, hue=segment_counts.index, palette = my_palette[:6])

# Ajouter les valeurs au-dessus de chaque barre
for i, value in enumerate(segment_counts.values):
    plt.text(i, value, str(value), ha='center', va='bottom')

plt.title('Nombre de clients par segment')
plt.xlabel('Segment')
plt.ylabel('Nombre de clients')
plt.xticks(rotation=45)
plt.show()
No description has been provided for this image
In [20]:
# Nombre de clients par segment RFM
segment_counts = rfm_df['Segment'].value_counts().reset_index()
segment_counts.columns = ['Segment', 'Count']

fig = px.treemap(segment_counts, path=['Segment'], values='Count')
fig.update_layout(
    title="Segments RFM - Nombre de clients",
    margin=dict(t=50, l=0, r=0, b=0)
)

fig.show()
In [21]:
# Valeurs monétaires des clients par segment RFM
segment_monetary = rfm_df.groupby('Segment', as_index=False)['monetary'].sum()

fig = px.treemap(segment_monetary, path=['Segment'], values='monetary')
fig.update_layout(
    title="Segments RFM - Monetary",
    margin=dict(t=50, l=0, r=0, b=0)  # Marge pour le titre
)

fig.show()
In [22]:
# Projection sur les 3 variables RFM - graphique 3D
fig = px.scatter_3d(
    rfm_df, x='recency', y='frequency', z='monetary',  color='Segment',
    title='Segmentation RFM Marketing'
)
fig.show()
In [23]:
# Pairplot des segments RFM
data = rfm_df[['recency', 'monetary','frequency', 'Segment']]
sns.pairplot(data,
             hue='Segment',
             palette=my_palette[:6],
             dropna = True,
             corner = True).fig.suptitle('Pairplot des Segments RFM', y=1.05)
plt.show()
No description has been provided for this image
In [24]:
# Standardisation (centrage-reduction) des valeurs des centroïdes
centroids_rfm_scaled = pd.DataFrame(scaler.fit_transform(centroids_rfm.values),
                                   index = centroids_rfm.index,
                                   columns = features_rfm )

# Reset index pour visualisation radar charts
centroids_rfm_scaled_df = centroids_rfm_scaled.reset_index()
centroids_rfm_scaled_df = centroids_rfm_scaled_df.rename(columns={'index':'Segment'})
In [25]:
# Représentation des centroïdes sur des radar charts
plt.figure(figsize=(10, 10))

# Boucle pour afficher les radars charts
for row in range(0, len(centroids_rfm_scaled_df.index)):
    make_spider(data=centroids_rfm_scaled_df,
                group='Segment',
                row=row,
                title='Cluster '+str(centroids_rfm_scaled_df['Segment'][row]),
                color=my_palette[row])
No description has been provided for this image
In [26]:
# Transformation du dataframe pour avoir les valeurs en colonne -> nécessaire pour la représentation en radar chart
centroids_rfm_scaled_melt = centroids_rfm_scaled.reset_index().melt(id_vars = 'Segment',
                                                                    value_vars = centroids_rfm_scaled.columns)
In [27]:
# Créer des traces pour chaque segment
traces = []
for segment in centroids_rfm_scaled_melt['Segment'].unique():
    segment_data = centroids_rfm_scaled_melt[centroids_rfm_scaled_melt['Segment'] == segment]

    # Ajouter le premier point à la fin pour fermer la ligne
    r_values = segment_data['value'].tolist() + [segment_data['value'].iloc[0]]
    theta_values = segment_data['variable'].tolist() + [segment_data['variable'].iloc[0]]

    trace = go.Scatterpolar(
        r=r_values,
        theta=theta_values,
        mode='lines',
        name=segment,
        fill='toself'  # Remplir l'aire sous la courbe
    )
    traces.append(trace)

# Créer la mise en page
layout = go.Layout(
    title="Comparaison des centroïdes des segments RFM"
)

# Créer la figure
fig = go.Figure(data=traces, layout=layout)

# Afficher la figure
fig.show()

Partie 2 - RFM avec méthodes d'apprentissage non supervisé¶

K-Means¶

Nombre optimum de clusters k¶

Différentes méthodes peuvent être utilisées pour déterminer le nombre optimal de clusters k :

  • Méthode du coude sur le distorsion score : distorsion score = moyenne de la somme des carrés des écarts de distances entre les individus d'un cluster et son centroïde

$$distortion = \frac{1}{n}\Sigma(distance(point, centroïde)^2)$$

  • Méthode du coude sur l'inertie : inertie = somme des carrés des écarts de distances entre les individus d'un cluster et son centroïde

$$inertie = \Sigma(distance(point, centroïde)^2)$$

  • Silhouette score / plot : Le coefficient de silhouette est la moyenne des différences entre la distance moyenne d'un point avec les points du même groupe que lui (cohésion) et la distance moyenne du point avec les points des autres groupes voisins (séparation). Plus le coefficient de silhouette est élevé, meilleure est la classification

Nombre optimal de clusters : L'ensemble des méthodes donnent comme optimum 4 clusters.

In [28]:
# définition du random_state et k range
k_min = 2
k_max = 10
random_state = 42
In [29]:
# Distortion score with KElbow visualizer
# Instanciation du modèle de clustering et du visualizer
km = KMeans(n_init=10, init='k-means++', random_state=random_state)
kvisualizer = KElbowVisualizer(km, k=(k_min, k_max))

# Entraînement du visualizer et affichage du graphique
kvisualizer.fit(X_rfm_scaled)
kvisualizer.show()
plt.show()

# Récupération de la valeur du coude
k_rfm = kvisualizer.elbow_value_
No description has been provided for this image
In [30]:
# Silhouette score
silhouette_scores = []

for k in range(k_min, k_max):
    kmeans = KMeans(n_clusters=k, n_init=10, init='k-means++', random_state=random_state)
    kmeans.fit(X_rfm_scaled)
    labels = kmeans.labels_
    silhouette_scores.append(silhouette_score(X_rfm_scaled, labels))

# Représentation graphique du Silhouette scor
plt.plot(range(k_min, k_max), silhouette_scores)
plt.title('Coefficient de silhouette pour chaque nombre de clusters k')
plt.xlabel('Nombre de clusters')
plt.ylabel('Coefficient de silhouette')

plt.show()
No description has been provided for this image
In [31]:
# Méthode du silhouette plot
# Instanciation du modèle kmeans 
km = KMeans(n_clusters=k_rfm, n_init=10, init='k-means++', random_state=random_state)
# Instanciation du SilhouetteVisualizer instance
visualizer = SilhouetteVisualizer(km, colors='yellowbrick')
# entraînement du visualizer
visualizer.fit(X_rfm_scaled)
plt.show()
No description has been provided for this image

Nombre de clusters = 4¶

  • Clustering
In [32]:
# Instanciation et Entraînement du modèle k-means
kmeans = KMeans(n_clusters=k_rfm, n_init=10, init='k-means++', random_state=random_state)
kmeans.fit(X_rfm_scaled)

# Stockage des clusters dans la variable labels
labels_rfm_k4 = kmeans.labels_

# Stockage des centroids dans une variable
centroids_kmeans_rfm_k4 = kmeans.cluster_centers_

# Nombre de clients par cluster
unique, counts = np.unique(labels_rfm_k4+1, return_counts=True)

dict(zip(unique, counts))
Out[32]:
{1: 37478, 2: 50390, 3: 2760, 4: 2414}
  • Stabilité des clusters

Les tests montrent que les clusters sont stables.

In [33]:
# Tests de stabilité des clusters
for i in range(3):
    kmeans_test = KMeans(n_clusters=k_rfm, n_init=10, init='k-means++')
    kmeans_test.fit(X_rfm_scaled)
    labels_test = kmeans_test.labels_
    unique, counts = np.unique(labels_test + 1, return_counts=True)
    print("Test", i + 1)
    print(dict(zip(unique, counts)))
Test 1
{1: 37478, 2: 50390, 3: 2414, 4: 2760}
Test 2
{1: 37478, 2: 50390, 3: 2760, 4: 2414}
Test 3
{1: 50394, 2: 37475, 3: 2760, 4: 2413}
  • Analyse des clusters
In [34]:
# Représentation des centroïdes sous forme de heatmap
centroids_kmeans_rfm_k4 = pd.DataFrame(centroids_kmeans_rfm_k4,
                                   index = np.unique(labels_rfm_k4+1),
                                   columns = features_rfm )

fig, ax = plt.subplots(figsize=(10, 5))
sns.heatmap(centroids_kmeans_rfm_k4.T, vmin=-2, vmax=2, annot=True, cmap="coolwarm", fmt="0.2f")
plt.show()
No description has been provided for this image
In [35]:
# Ajouter 'Cluster_kmeans' au df et changer le type en string pour un affichage du graphique en couleur discrète
rfm_k4_df = rfm_df.copy()
rfm_k4_df.loc[:,'Cluster_kmeans'] = labels_rfm_k4+1
rfm_k4_df.loc[:,'Cluster_kmeans'] = rfm_k4_df['Cluster_kmeans'].astype(str)
In [36]:
# Ajouter l'index comme variable
centroids_kmeans_rfm_k4 = centroids_kmeans_rfm_k4.reset_index()
centroids_kmeans_rfm_k4 = centroids_kmeans_rfm_k4.rename(columns={'index':'Cluster_kmeans'})
In [37]:
# Critères d'identification des clusters
min_recency = min(centroids_kmeans_rfm_k4.iloc[:,1])
max_recency = max(centroids_kmeans_rfm_k4.iloc[:,1])
max_frequency = max(centroids_kmeans_rfm_k4.iloc[:,2])
max_monetary = max(centroids_kmeans_rfm_k4.iloc[:,3])

# Initialisation de la colonne 'segment'
centroids_kmeans_rfm_k4['segment'] = 'Other'

# Attribution des clusters
for row in range(0, len(centroids_kmeans_rfm_k4.index)):
    if centroids_kmeans_rfm_k4['recency'][row] == min_recency:
        centroids_kmeans_rfm_k4.loc[row,'segment'] = 'New customer'

    elif centroids_kmeans_rfm_k4['recency'][row] == max_recency:
        centroids_kmeans_rfm_k4.loc[row,'segment'] = 'Hibernating'

    elif centroids_kmeans_rfm_k4['frequency'][row] == max_frequency:
        centroids_kmeans_rfm_k4.loc[row,'segment'] = 'Champions'

    elif centroids_kmeans_rfm_k4['monetary'][row] == max_monetary:
        centroids_kmeans_rfm_k4.loc[row,'segment'] = "Can't lose"

centroids_kmeans_rfm_k4['Cluster_kmeans'] = centroids_kmeans_rfm_k4['Cluster_kmeans'].astype(str)

rfm_k4_df = pd.merge(rfm_k4_df, centroids_kmeans_rfm_k4[['Cluster_kmeans', 'segment']], on='Cluster_kmeans')
In [38]:
# Représentation des centroïdes sur des radar charts
plt.figure(figsize=(7, 7))

# Boucle pour afficher les radars charts
for row in range(0, len(centroids_kmeans_rfm_k4.index)):
    make_spider(data=centroids_kmeans_rfm_k4.sort_values('Cluster_kmeans', ascending=True).iloc[:,1:],
                group='segment',
                row=row,
                title='Cluster '+str(centroids_kmeans_rfm_k4['segment'][row]),
                color=my_palette[row])
No description has been provided for this image
In [39]:
# Répartition des clients par segment
segment_counts = rfm_k4_df.groupby(['segment','Cluster_kmeans'], as_index=False).agg(count = ('recency','count')).sort_values('Cluster_kmeans', ascending=True)
segments = list(segment_counts['segment'].unique())
colors = my_palette[:len(segments)]

plt.pie(x=segment_counts['count'],
        labels=segments,
        autopct='%.1f%%',
        pctdistance=0.8,
        colors=colors)

plt.title("Répartition des clients par segment")
plt.tight_layout()
plt.show()
No description has been provided for this image
In [40]:
# Bloxplot par cluster et par variable
fig, ax = plt.subplots(1, 3, figsize=(20,5), tight_layout=True)

for i, col in enumerate(rfm_k4_df.iloc[:,:3].columns):
    sns.boxplot(data=rfm_k4_df.sort_values('Cluster_kmeans', ascending=True),
                x='segment',
                y=col,
                ax=ax[i],
                hue='segment',
                showmeans=True,
                showfliers=False,
                meanprops=meanprops,
               palette = my_palette[:4])
    ax[i].set_title(f'Distribution des données des clusters pour la variable {col}', wrap=True)

plt.show()
No description has been provided for this image
In [41]:
# Projection des clusters
fig = px.scatter_3d(
    rfm_k4_df.sort_values('Cluster_kmeans', ascending=True), x='recency', y='frequency', z='monetary', color='segment',
    title='Clustering K-means - RFM'
)
fig.show()
In [42]:
# Transformation du dataframe pour avoir les valeurs en colonne -> nécessaire pour la représentation en radar chart
centroids_kmeans_rfm_k4_melt = centroids_kmeans_rfm_k4.reset_index().melt(id_vars = ['segment', 'Cluster_kmeans'],
                                                                      value_vars = features_rfm)
centroids_kmeans_rfm_k4_melt = centroids_kmeans_rfm_k4_melt.sort_values(['Cluster_kmeans', 'variable'])\
                                                        .drop(columns='Cluster_kmeans')
In [43]:
# Créer des traces pour chaque segment
traces = []
for segment in centroids_kmeans_rfm_k4_melt['segment'].unique():
    segment_data = centroids_kmeans_rfm_k4_melt[centroids_kmeans_rfm_k4_melt['segment'] == segment]

    # Ajouter le premier point à la fin pour fermer la ligne
    r_values = segment_data['value'].tolist() + [segment_data['value'].iloc[0]]
    theta_values = segment_data['variable'].tolist() + [segment_data['variable'].iloc[0]]

    trace = go.Scatterpolar(
        r=r_values,
        theta=theta_values,
        mode='lines',
        name=str(segment),
        fill='toself'  # Remplir l'aire sous la courbe
    )
    traces.append(trace)

# Créer la mise en page
layout = go.Layout(
    title="Comparaison des centroïdes des Cluster_kmeans RFM"
)

# Créer la figure
fig = go.Figure(data=traces, layout=layout)

# Afficher la figure
fig.show()

Nombre de clusters = 6¶

Testons maintenant avec k=6 clusters si nous retrouvons les clusters identifiés avec la RFM marketing.

  • Clustering
In [44]:
# Définition du nombre de clusters
k = 6
In [45]:
# Instanciation et Entraînement du modèle k-means
kmeans = KMeans(n_clusters=k,n_init=10, init='k-means++', random_state=random_state)
kmeans.fit(X_rfm_scaled)

# Stockage des clusters dans la variable labels
labels_rfm_k6 = kmeans.labels_

# Stockage des centroids dans une variable
centroids_kmeans_rfm_k6 = kmeans.cluster_centers_

# Nombre de clients par cluster
unique, counts = np.unique(labels_rfm_k6+1, return_counts=True)

dict(zip(unique, counts))
Out[45]:
{1: 32327, 2: 2766, 3: 33016, 4: 504, 5: 20367, 6: 4062}
  • Stabilité des clusters

Les tests montrent que les clusters sont stables.

In [46]:
# Tests de stabilité des clusters
for i in range(3):
    kmeans_test = KMeans(n_clusters=k, n_init=10, init='k-means++')
    kmeans_test.fit(X_rfm_scaled)
    labels_test = kmeans_test.labels_
    unique, counts = np.unique(labels_test + 1, return_counts=True)
    print("Test", i + 1)
    print(dict(zip(unique, counts)))
Test 1
{1: 32454, 2: 2767, 3: 32993, 4: 461, 5: 20454, 6: 3913}
Test 2
{1: 20417, 2: 32971, 3: 2767, 4: 499, 5: 32386, 6: 4002}
Test 3
{1: 32903, 2: 20488, 3: 2767, 4: 462, 5: 3918, 6: 32504}
  • Analyse des clusters
In [47]:
# Représentation des centroïdes sous forme de heatmap
centroids_kmeans_rfm_k6 = pd.DataFrame(centroids_kmeans_rfm_k6,
                                   index = np.unique(labels_rfm_k6+1),
                                   columns = features_rfm )

fig, ax = plt.subplots(figsize=(10, 5))
sns.heatmap(centroids_kmeans_rfm_k6.T, vmin=-2, vmax=2, annot=True, cmap="coolwarm", fmt="0.2f")
plt.show()
No description has been provided for this image
In [48]:
# Ajouter 'Cluster_kmeans' au df et changer le type en string pour un affichage du graphique en couleur discrète
rfm_k6_df = rfm_df.copy()
rfm_k6_df.loc[:,'Cluster_kmeans'] = labels_rfm_k6+1
rfm_k6_df.loc[:,'Cluster_kmeans'] = rfm_k6_df['Cluster_kmeans'].astype(str)
In [49]:
# Représentation des centroïdes sur des radar charts
centroids_kmeans_rfm_k6 = centroids_kmeans_rfm_k6.reset_index()
centroids_kmeans_rfm_k6 = centroids_kmeans_rfm_k6.rename(columns={'index':'Cluster_kmeans'})
In [50]:
# Critères d'identification des clusters
min_recency = min(centroids_kmeans_rfm_k6.iloc[:,1])
max_recency = max(centroids_kmeans_rfm_k6.iloc[:,1])
max_frequency = max(centroids_kmeans_rfm_k6.iloc[:,2])
min_monetary = min(centroids_kmeans_rfm_k6.iloc[:,3])
max_monetary = max(centroids_kmeans_rfm_k6.iloc[:,3])

# Initialisation de la colonne 'segment'
centroids_kmeans_rfm_k6['segment'] = 'Other'

# Attribution des clusters
for row in range(0, len(centroids_kmeans_rfm_k6.index)):
    if centroids_kmeans_rfm_k6['recency'][row] == min_recency:
        centroids_kmeans_rfm_k6.loc[row,'segment'] = 'New customers'

    elif centroids_kmeans_rfm_k6['recency'][row] == max_recency:
        centroids_kmeans_rfm_k6.loc[row,'segment'] = 'Hibernating'

    elif centroids_kmeans_rfm_k6['frequency'][row] == max_frequency:
        centroids_kmeans_rfm_k6.loc[row,'segment'] = 'Champions'

    elif centroids_kmeans_rfm_k6['monetary'][row] == max_monetary:
        centroids_kmeans_rfm_k6.loc[row,'segment'] = "Can't lose"

    elif min_monetary < centroids_kmeans_rfm_k6['monetary'][row] < max_monetary:
        centroids_kmeans_rfm_k6.loc[row,'segment'] = 'Need attention'

    elif min_recency < centroids_kmeans_rfm_k6['recency'][row] < max_recency:
        centroids_kmeans_rfm_k6.loc[row,'segment'] = 'Potential loyalists'

centroids_kmeans_rfm_k6['Cluster_kmeans'] = centroids_kmeans_rfm_k6['Cluster_kmeans'].astype(str)

rfm_k6_df = pd.merge(rfm_k6_df, centroids_kmeans_rfm_k6[['Cluster_kmeans', 'segment']], on='Cluster_kmeans')
In [51]:
# Représentation des centroïdes sur des radar charts
plt.figure(figsize=(10, 10))

# Boucle pour afficher les radars charts
for row in range(0, len(centroids_kmeans_rfm_k6.index)):
    make_spider(data=centroids_kmeans_rfm_k6.sort_values('Cluster_kmeans', ascending=True).iloc[:,1:],
                group='segment',
                row=row,
                title='Cluster '+str(centroids_kmeans_rfm_k6['segment'][row]),
                color=my_palette[row])
No description has been provided for this image
In [52]:
# Répartition des clients par segment
segment_counts = rfm_k6_df.groupby(['segment','Cluster_kmeans'], as_index=False).agg(count = ('recency','count')).sort_values('Cluster_kmeans', ascending=True)
segments = list(segment_counts['segment'].unique())
colors = my_palette[:len(segments)]

plt.pie(x=segment_counts['count'],
        labels=segments,
        autopct='%.1f%%',
        pctdistance=0.8,
        colors=colors)

plt.title("Répartition des clients par segment")
plt.tight_layout()
plt.show()
No description has been provided for this image
In [53]:
# Bloxplot par cluster et par variable
fig, ax = plt.subplots(1, 3, figsize=(20,5), tight_layout=True)

for i, col in enumerate(rfm_k6_df.iloc[:,:3].columns):
    sns.boxplot(data=rfm_k6_df.sort_values('Cluster_kmeans', ascending=True),
                x='segment',
                y=col,
                ax=ax[i],
                hue='segment',
                showmeans=True,
                showfliers=False,
                meanprops=meanprops,
               palette = my_palette[:6])
    ax[i].set_title(f'Distribution des données des clusters pour la variable {col}', wrap=True)
    ax[i].set_xticks(ax[i].get_xticks())
    ax[i].set_xticklabels(ax[i].get_xticklabels(), rotation=45)

plt.show()
No description has been provided for this image
In [54]:
# Projection des clusters
fig = px.scatter_3d(
    rfm_k6_df.sort_values('Cluster_kmeans', ascending=True), x='recency', y='frequency', z='monetary', color='segment',
    title='Clustering K-means - RFM'
)
fig.show()
In [55]:
# Transformation du dataframe pour avoir les valeurs en colonne -> nécessaire pour la représentation en radar chart
centroids_kmeans_rfm_k6_melt = centroids_kmeans_rfm_k6.reset_index().melt(id_vars = ['segment', 'Cluster_kmeans'],
                                                                      value_vars = features_rfm)
centroids_kmeans_rfm_k6_melt = centroids_kmeans_rfm_k6_melt.sort_values(['Cluster_kmeans', 'variable'])\
                                                        .drop(columns='Cluster_kmeans')
In [56]:
# Créer des traces pour chaque segment
traces = []
for segment in centroids_kmeans_rfm_k6_melt['segment'].unique():
    segment_data = centroids_kmeans_rfm_k6_melt[centroids_kmeans_rfm_k6_melt['segment'] == segment]

    # Ajouter le premier point à la fin pour fermer la ligne
    r_values = segment_data['value'].tolist() + [segment_data['value'].iloc[0]]
    theta_values = segment_data['variable'].tolist() + [segment_data['variable'].iloc[0]]

    trace = go.Scatterpolar(
        r=r_values,
        theta=theta_values,
        mode='lines',
        name=str(segment),
        fill='toself'  # Remplir l'aire sous la courbe
    )
    traces.append(trace)

# Créer la mise en page
layout = go.Layout(
    title="Comparaison des centroïdes des Cluster_kmeans RFM"
)

# Créer la figure
fig = go.Figure(data=traces, layout=layout)

# Afficher la figure
fig.show()

Classification Ascendante Hierarchique¶

Le jeu de données est trop important pour l'algorithme de CAH. Cette méthode n'est pas concluante.

Nous pouvons cependant tester la méthode sur un échantillon de notre jeu de données.

Méthode 1 - Echantillonage sur le jeu de données¶

In [57]:
# Échantillonnage aléatoire de 10% des données
sampled_data = rfm_df[['recency','frequency','monetary']].sample(frac=0.1, random_state=0)

display(Markdown(f"L'échantillon contient {sampled_data.shape[0]} clients."))

# Standardisation des données
X_sampled = sampled_data.values # Matrice des données
names_sample = sampled_data.index # Noms des individus
features = ['recency','frequency','monetary'] # Noms des variables

scaler_sample = StandardScaler() # Instanciation du scaler
X_sampled_scaled = scaler_sample.fit_transform(X_sampled) # Données scalées

L'échantillon contient 9304 clients.

  • Dendogramme
In [58]:
# Linkage avec la méthode de Ward
Z = linkage(X_sampled_scaled, method="ward")
In [59]:
# Affichage du dendrogramme
fig, ax = plt.subplots(1, 1, figsize=(30,7))
dendogram_top = dendrogram(Z, ax=ax, orientation='top')
plt.title("Hierarchical Clustering Dendrogram", fontsize=15)
ax.set_ylabel("Distance")
ax.set_xlabel("Client")
ax.tick_params(axis='y', which='major', labelsize=15)
No description has been provided for this image
In [60]:
# définition du nombre de clusters k
k = 4
In [61]:
# Affichage du dendrogramme limité à k clusters
fig, ax = plt.subplots(1, 1, figsize=(7, 5))
dendogram_truncate = dendrogram(Z, p=k, truncate_mode="lastp", ax=ax) # découpage pour n'afficher que k clusters
plt.title("Hierarchical Clustering Dendrogram")
plt.xlabel("Number of points in node (or index of point if no parenthesis).")
plt.ylabel("Distance.")
plt.show()
No description has been provided for this image
In [62]:
# Définition des clusters
clusters = fcluster(Z, k, criterion='maxclust')
  • Visualisation des clusters
In [63]:
# Coordonnées des centroïdes
centroid_CAH = pd.DataFrame(data=X_sampled_scaled, columns=['recency', 'frequency','monetary'])
centroid_CAH['Cluster_CAH'] = clusters
centroid_CAH = centroid_CAH.groupby('Cluster_CAH').mean()
In [64]:
# Représentation des centroïdes sous forme de heatmap
fig, ax = plt.subplots(figsize=(10, 5))
sns.heatmap(centroid_CAH.T, vmin=-2, vmax=2, annot=True, cmap="coolwarm", fmt="0.2f")
plt.show()
No description has been provided for this image
In [65]:
# Ajouter 'Cluster_CAH à sampled_data et changer le type en string pour un affichage du graphique en couleur discrète
sampled_data['Cluster_CAH'] = clusters
sampled_data['Cluster_CAH'] = sampled_data['Cluster_CAH'].astype(str)
In [66]:
# Projection sur les 3 variables RFM - graphique 3D
fig = px.scatter_3d(
    sampled_data.sort_values('Cluster_CAH', ascending=True), x='recency', y='frequency', z='monetary', color='Cluster_CAH',
    title='Clustering CAH'
)
fig.show()
In [67]:
# Représentation des centroïdes sur des radar charts
data = centroid_CAH.reset_index()

plt.figure(figsize=(7, 7))

# Boucle pour afficher les radars charts
for row in range(0, len(data.index)):
    make_spider(data=data,
                group='Cluster_CAH',
                row=row,
                title='Cluster '+str(data['Cluster_CAH'][row]),
                color=my_palette[row])
No description has been provided for this image

Méthode 2 - Clustering k-means puis CAH¶

  • Clustering K-means k=1000
In [68]:
k=1000
X = X_rfm_scaled
In [69]:
# Instanciation et Entraînement du modèle k-means
kmeans = KMeans(n_clusters=k,n_init=10, init='k-means++')
kmeans.fit(X)

# Stockage des clusters dans la variable labels
labels = kmeans.labels_

# Stockage des centroids dans une variable
centroids_kmeans_rfm = kmeans.cluster_centers_
  • Dendogramme
In [70]:
# Linkage avec la méthode de Ward
Z = linkage(centroids_kmeans_rfm, method="ward")
In [71]:
# Affichage du dendrogramme
fig, ax = plt.subplots(1, 1, figsize=(30,7))
dendogram_top = dendrogram(Z, ax=ax, orientation='top')
plt.title("Hierarchical Clustering Dendrogram", fontsize=15)
ax.set_ylabel("Distance")
ax.set_xlabel("Groupe de Clients")
ax.tick_params(axis='y', which='major', labelsize=15)
No description has been provided for this image
In [72]:
# définition du nombre de clusters k
k = 4
In [73]:
# Affichage du dendrogramme limité à k clusters
fig, ax = plt.subplots(1, 1, figsize=(7, 5))
dendogram_truncate = dendrogram(Z, p=k, truncate_mode="lastp", ax=ax) # découpage pour n'afficher que k clusters
plt.title("Hierarchical Clustering Dendrogram")
plt.xlabel("Number of points in node (or index of point if no parenthesis).")
plt.ylabel("Distance.")
plt.show()
No description has been provided for this image
In [74]:
# Définition des clusters
clusters = fcluster(Z, k, criterion='maxclust')
  • Visualisation des clusters
In [75]:
# Coordonnées des centroïdes
centroid_CAH = pd.DataFrame(data=centroids_kmeans_rfm, columns=['recency', 'frequency','monetary'])
centroid_CAH['Cluster_CAH'] = clusters
centroid_CAH = centroid_CAH.groupby('Cluster_CAH').mean()
In [76]:
# Représentation des centroïdes sous forme de heatmap
fig, ax = plt.subplots(figsize=(10, 5))
sns.heatmap(centroid_CAH.T, vmin=-5, vmax=5, annot=True, cmap="coolwarm", fmt="0.2f")
plt.show()
No description has been provided for this image

DBScan¶

Density-Based Spatial Clustering of Applications with Noise

L'algorithme DBSCAN est difficile à utiliser en très grande dimension et en effet, il ne fonctionne pas ici.

Clustering k-means puis DBScan sur les clusters¶

Les résultats du k-means k=1000 réalisés précédemment sont utilisés ici

  • Déterminer le paramètre epsilon optimum avec la méthode du coude
In [77]:
# Calculer les distances des plus proches voisins
neighb = NearestNeighbors(n_neighbors=2*centroids_kmeans_rfm.shape[1]) # Créer un objet NearestNeighbors

nbrs=neighb.fit(centroids_kmeans_rfm) # Entraînement du modèle

distances,indices=nbrs.kneighbors(centroids_kmeans_rfm) # trouver les plus proches voisins
In [78]:
# Trier et afficher les résultats des distances
distances = np.sort(distances, axis = 0) # classer les distances
distances = distances[:, 1] # sélectionner la seconde colonne avec les distances
plt.plot(distances) # tracer les distances
plt.show()
No description has been provided for this image
In [79]:
# Zoom sur le coude
plt.xlim(990,1000)
plt.plot(distances)

best_k = 997

# only one line may be specified; full height
plt.axhline(y = distances[best_k], color = 'red', ls=':',label = 'axvline - full height')
print(f'best epsilon : {round(distances[best_k],2)}')
best_epsilon = round(distances[best_k],2)
best epsilon : 9.6
No description has been provided for this image
In [80]:
# Instanciation d'un objet DBSCAN avec les paramètres optimums
dbscan = DBSCAN(eps=best_epsilon, min_samples=2*centroids_kmeans_rfm.shape[1])

# Entraînement du modèle
dbscan.fit(centroids_kmeans_rfm)
Out[80]:
DBSCAN(eps=9.6, min_samples=6)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
DBSCAN(eps=9.6, min_samples=6)
In [81]:
# Stockage des clusters dans la variable labels
labels_dbscan = dbscan.labels_

# Number of clusters in labels, ignoring noise if present.
n_clusters_ = len(set(labels_dbscan)) - (1 if -1 in labels_dbscan else 0)
n_noise_ = list(labels_dbscan).count(-1)

print("Estimated number of clusters: %d" % n_clusters_)
print("Estimated number of noise points: %d" % n_noise_)
Estimated number of clusters: 1
Estimated number of noise points: 2
In [82]:
# Projection sur les 3 variables RFM - graphique 3D
fig = px.scatter_3d(
    centroids_kmeans_rfm, x=0, y=1, z=2, color=labels_dbscan,
    title='Clustering DBScan'
)
fig.show()

Le modèle DBScan n'est pas pertinent ici : la distribution de la fréquence non normale rend les données trop denses et l'algorithme ne peut pas détecter de clusters.

Partie 3 - Clustering avec les variables RFM et Satisfaction client¶

La méthode utilisée ici sera le k-means, méthode la plus adaptée d'après les essais de la partie 2. Les variables testées sont : RFM + satisfaction client

Standardisation des données¶

In [83]:
rfms_df = df_cleaned[['recency','frequency','monetary', 'review_score_class']]

# Centrage et réduction des variables
X_rfms = rfms_df.values # Matrice des données
names = rfms_df.index # Noms des individus
features_rfms = rfms_df.columns # Noms des variables

scaler = StandardScaler() # Instanciation du scaler
X_rfms_scaled = scaler.fit_transform(X_rfms) # Données scalées

Nombre optimal de clusters¶

In [84]:
# définitiondu random_state et k range
k_min = 2
k_max = 10
random_state = 42
In [85]:
# Distortion score with KElbow visualizer
# Instanciation du modèle de clustering et du visualizer
km = KMeans(n_init = 10, init='k-means++', random_state=random_state)
kvisualizer = KElbowVisualizer(km, k=(k_min, k_max))

# Entraînement du visualizer et affichage du graphique
kvisualizer.fit(X_rfms_scaled)
kvisualizer.show()

# Récupération de la valeur du coude
k_rfms = kvisualizer.elbow_value_
No description has been provided for this image
In [86]:
# Silhouette score
silhouette_scores = []

for k in range(k_min, k_max):
    kmeans = KMeans(n_clusters=k, n_init=10, init='k-means++', random_state=random_state)
    kmeans.fit(X_rfms_scaled)
    labels = kmeans.labels_
    silhouette_scores.append(silhouette_score(X_rfm_scaled, labels))

# Représentation graphique du Silhouette scor
plt.plot(range(k_min, k_max), silhouette_scores)
plt.title('Coefficient de silhouette pour chaque nombre de clusters k')
plt.xlabel('Nombre de clusters')
plt.ylabel('Coefficient de silhouette')

plt.show()
No description has been provided for this image
In [87]:
# Méthode du silhouette plot
# Instanciation du modèle kmeans 
km = KMeans(n_clusters=k_rfms, n_init=10, init='k-means++', random_state=random_state)
# Instanciation du SilhouetteVisualizer instance
visualizer = SilhouetteVisualizer(km, colors='yellowbrick')
# entraînement du visualizer
visualizer.fit(X_rfm_scaled)
plt.show()
No description has been provided for this image

Clustering¶

In [88]:
# Instanciation et Entraînement du modèle k-means
kmeans = KMeans(n_clusters=k_rfms,n_init=10, init='k-means++', random_state=random_state)
kmeans.fit(X_rfms_scaled)

# Stockage des clusters dans la variable labels
labels_rfms = kmeans.labels_

# Stockage des centroids dans une variable
centroids_kmeans_rfms = kmeans.cluster_centers_

# Nombre de clients par cluster
unique, counts = np.unique(labels_rfms+1, return_counts=True)

dict(zip(unique, counts))
Out[88]:
{1: 29915, 2: 18724, 3: 39641, 4: 2760, 5: 2002}
  • Stabilité des clusters

Les tests montrent que les clusters sont stables.

In [89]:
# Tests de stabilité des clusters
for i in range(3):
    kmeans_test = KMeans(n_clusters=k_rfms, n_init=10, init='k-means++')
    kmeans_test.fit(X_rfms_scaled)
    labels_test = kmeans_test.labels_
    unique, counts = np.unique(labels_test + 1, return_counts=True)
    print("Test", i + 1)
    print(dict(zip(unique, counts)))
Test 1
{1: 29915, 2: 39641, 3: 18724, 4: 2760, 5: 2002}
Test 2
{1: 2760, 2: 39641, 3: 18724, 4: 29915, 5: 2002}
Test 3
{1: 39641, 2: 18724, 3: 2760, 4: 2002, 5: 29915}

Analyse des clusters¶

In [90]:
# Représentation des centroïdes sous forme de heatmap
centroids_kmeans_rfms = pd.DataFrame(centroids_kmeans_rfms,
                                   index = np.unique(labels_rfms+1),
                                   columns = features_rfms )

fig, ax = plt.subplots(figsize=(10, 5))
sns.heatmap(centroids_kmeans_rfms.T, vmin=-2, vmax=2, annot=True, cmap="coolwarm", fmt="0.2f")
plt.show()
No description has been provided for this image
In [91]:
# Ajouter 'Cluster_kmeans au df et changer le type en string pour un affichage du graphique en couleur discrète
rfms_df = rfms_df.copy()
rfms_df.loc[:, 'Cluster_kmeans'] = labels_rfms + 1
rfms_df.loc[:, 'Cluster_kmeans'] = rfms_df['Cluster_kmeans'].astype(str)
In [92]:
# Ajout de l'index 'cluster_kmeans' en variable
centroids_kmeans_rfms = centroids_kmeans_rfms.reset_index()
centroids_kmeans_rfms = centroids_kmeans_rfms.rename(columns={'index':'Cluster_kmeans'})
In [93]:
# Identification des clusters
min_recency = min(centroids_kmeans_rfms.iloc[:,1])
max_recency = max(centroids_kmeans_rfms.iloc[:,1])
max_frequency = max(centroids_kmeans_rfms.iloc[:,2])
max_monetary = max(centroids_kmeans_rfms.iloc[:,3])
min_review_score_class = min(centroids_kmeans_rfms.iloc[:,4])

# Initialisation de la colonne 'segment'
centroids_kmeans_rfms['segment'] = 'Other'

for row in range(0, len(centroids_kmeans_rfms.index)):
    if centroids_kmeans_rfms['recency'][row] == max_recency:
        centroids_kmeans_rfms.loc[row,'segment'] = 'Hibernating'

    elif centroids_kmeans_rfms['recency'][row] == min_recency:
        centroids_kmeans_rfms.loc[row,'segment'] = 'New customer'

    elif centroids_kmeans_rfms['frequency'][row] == max_frequency:
        centroids_kmeans_rfms.loc[row,'segment'] = 'Loyalist'

    elif centroids_kmeans_rfms['monetary'][row] == max_monetary:
        centroids_kmeans_rfms.loc[row,'segment'] = "Can't lose"

    elif centroids_kmeans_rfms['review_score_class'][row] == min_review_score_class:
        centroids_kmeans_rfms.loc[row,'segment'] = "Dissatisfied"

centroids_kmeans_rfms['Cluster_kmeans'] = centroids_kmeans_rfms['Cluster_kmeans'].astype(str)

rfms_df = pd.merge(rfms_df, centroids_kmeans_rfms[['Cluster_kmeans', 'segment']], on='Cluster_kmeans')
In [94]:
# Représentation des centroïdes sur des radar charts
plt.figure(figsize=(15, 20))

# Boucle pour afficher les radars charts
for row in range(0, len(centroids_kmeans_rfms.index)):
    make_spider(data=centroids_kmeans_rfms.iloc[:,1:],
                group='segment',
                row=row,
                title='Cluster '+str(centroids_kmeans_rfms['segment'][row]),
                color=my_palette[row])
No description has been provided for this image
In [95]:
# Répartition des clients par segment
segment_counts = rfms_df.groupby(['segment','Cluster_kmeans'], as_index=False).agg(count = ('recency','count')).sort_values('Cluster_kmeans', ascending=True)
segments = list(segment_counts['segment'].unique())
colors = my_palette[:len(segments)]

plt.pie(x=segment_counts['count'],
        labels=segments,
        autopct='%.1f%%',
        pctdistance=0.8,
        colors=colors)

plt.title("Répartition des clients par segment")
plt.tight_layout()
plt.show()
No description has been provided for this image
In [96]:
# Bloxplot par cluster et par variable
fig, ax = plt.subplots(1, 4, figsize=(25,5), tight_layout=True)

for i, col in enumerate(rfms_df.iloc[:,:4].columns):
    sns.boxplot(data=rfms_df.sort_values('Cluster_kmeans'),
                x='segment',
                y=col,
                ax=ax[i],
                hue='segment',
                showmeans=True,
                showfliers=False,
                meanprops=meanprops,
               palette = my_palette[:k_rfms])
    ax[i].set_title(f'Distribution des données des clusters pour la variable {col}', wrap=True)

plt.show()
No description has been provided for this image
In [97]:
# Projection des clusters
fig = px.scatter_3d(
    rfms_df.sort_values('Cluster_kmeans', ascending=True), x='recency', y='frequency', z='monetary', color='segment',
    title='Clustering K-means - RFM + satisfaction'
)
fig.show()
In [98]:
# Projection des clusters
fig = px.scatter_3d(
    rfms_df.sort_values('Cluster_kmeans', ascending=True),
    x='recency',
    y='review_score_class',
    z='monetary',
    color='segment',
    title='Clustering K-means - RFM + satisfaction'
)
fig.show()
In [99]:
# Transformation du dataframe pour avoir les valeurs en colonne -> nécessaire pour la représentation en radar chart
centroids_kmeans_rfms_melt = centroids_kmeans_rfms.reset_index().melt(id_vars = ['segment', 'Cluster_kmeans'],
                                                                      value_vars = features_rfms)
centroids_kmeans_rfms_melt = centroids_kmeans_rfms_melt.sort_values(['Cluster_kmeans', 'variable'])\
                                                        .drop(columns='Cluster_kmeans')
In [100]:
# Créer des traces pour chaque segment
traces = []
for segment in centroids_kmeans_rfms_melt['segment'].unique():
    segment_data = centroids_kmeans_rfms_melt[centroids_kmeans_rfms_melt['segment'] == segment]

    # Ajouter le premier point à la fin pour fermer la ligne
    r_values = segment_data['value'].tolist() + [segment_data['value'].iloc[0]]
    theta_values = segment_data['variable'].tolist() + [segment_data['variable'].iloc[0]]

    trace = go.Scatterpolar(
        r=r_values,
        theta=theta_values,
        mode='lines',
        name=str(segment),
        fill='toself'  # Remplir l'aire sous la courbe
    )
    traces.append(trace)

# Créer la mise en page
layout = go.Layout(
    title="Comparaison des centroïdes des Cluster_kmeans RFMS"
)

# Créer la figure
fig = go.Figure(data=traces, layout=layout)

# Afficher la figure
fig.show()

Partie 4 - Clustering avec plus de variables¶

  • Essai avec l'ensemble des variables (items / catégorie) : trop de features en entrée de l'ACP (>4000 après encodage)
  • Essai avec 'geolocation_lat' + 'geolocation_lng' : non pertinent et ne permet pas de une bonne projection des individus. Une information du niveau de revenu par quartier serait plus pertinente.
  • Essai avec 'customer_state' + encodage => non pertinent

Réduction de dimensions - PCA¶

  • Transformation des variables
In [101]:
df_acp = df_cleaned[[  'recency',
                       'frequency',
                       'monetary',
                       'average_basket_amount',
                       'review_score_class',
                       'mean_delivery_delay',
                       'average_basket',
                       'total_items',
                       'geolocation_lat',
                       'geolocation_lng'
                    ]]
In [102]:
X = df_acp.values # Matrice des données
names = df_acp.index # Noms des individus
features = df_acp.columns # Noms des variables
p = df_acp.shape[1] # nb de variables
In [103]:
# Instanciation
scaler = StandardScaler()

# Fit and transform
X_scaled = scaler.fit_transform(X)
In [104]:
df_acp.shape
Out[104]:
(93042, 10)
  • Analyse en Composantes Principales
In [105]:
# Instanciation de l'ACP
pca = PCA()

# Entraînement sur les données scalées
pca.fit(X_scaled)
Out[105]:
PCA()
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
PCA()
In [106]:
scree = pd.DataFrame(
    {
        "Dimension" : ["Dim" + str(x + 1) for x in range(p)],
        "Variance expliquée" : pca.explained_variance_,
        "% variance expliquée" : np.round(pca.explained_variance_ratio_ * 100),
        "% cum. var. expliquée" : np.round(np.cumsum(pca.explained_variance_ratio_) * 100)
    }
)
scree
Out[106]:
Dimension Variance expliquée % variance expliquée % cum. var. expliquée
0 Dim1 2.300320 23.0 23.0
1 Dim2 1.603475 16.0 39.0
2 Dim3 1.431477 14.0 53.0
3 Dim4 1.224288 12.0 66.0
4 Dim5 1.021134 10.0 76.0
5 Dim6 0.979954 10.0 86.0
6 Dim7 0.737780 7.0 93.0
7 Dim8 0.544252 5.0 98.0
8 Dim9 0.150137 2.0 100.0
9 Dim10 0.007290 0.0 100.0
In [107]:
# liste des composants (indice des composantes)
x_list = range(1, p+1)
list(x_list)

# Représentation graphiques des valeurs propres
plt.bar(x_list, scree['% variance expliquée'])
plt.plot(x_list, scree['% cum. var. expliquée'],c="red",marker='o')
plt.xlabel("rang de l'axe d'inertie")
plt.ylabel("pourcentage d'inertie")
plt.title("Eboulis des valeurs propres")
plt.show(block=False)
No description has been provided for this image
In [108]:
# Représentation graphiques des valeurs propres - méthode du coude
plt.plot(x_list, scree['% variance expliquée'],marker='o')
plt.xlabel("rang de l'axe d'inertie")
plt.ylabel("pourcentage d'inertie")
plt.title("Eboulis des valeurs propres")
plt.show(block=False)
No description has been provided for this image
In [109]:
# Nombre de composantes principales
n_components = 5

# Instanciation de l'ACP
pca = PCA(n_components=n_components)

# Entraînement sur les données scalées
pca.fit(X_scaled)
Out[109]:
PCA(n_components=5)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
PCA(n_components=5)
In [110]:
# Principal Components = PCs
pcs = pd.DataFrame(pca.components_)
In [111]:
# affichage de 'features' pour les colonnes et Fi en index
pcs.columns = features

# liste des composants (indice des composantes)
x_list = range(1, n_components+1)
list(x_list)

pcs.index = [f"PC{i}" for i in x_list]
pcs.round(2)

# Représentation sous forme de heatmap des 5 premières composantes
fig, ax = plt.subplots(figsize=(20, 10))
sns.heatmap(pcs.T.iloc[:,:n_components], vmin=-1, vmax=1, annot=True, cmap="coolwarm", fmt="0.2f")
plt.show()
No description has been provided for this image
  • PC1 : plus le client achète d'articles, plus il dépense
  • PC2 : clients qui achètent beaucoup d'articles à petits prix
  • PC3 : localisation geographique
  • PC4 : plus le délai de livraison est rapide, plus le client est satisfait
  • PC5 : plus le dernier achat est récent, plus la fréquence est élevée

Nous retrouvons ici la corrélation entre 'monetary' et 'average_basket_amount', 'total_items' et 'average_basket'.

In [112]:
# Graphe de corrélation pour PC1 et PC2
correlation_graph(pca, (0,1), features)
No description has been provided for this image
In [113]:
# Graphe de corrélation pour PC3 et PC4
correlation_graph(pca, (2,3), features)
No description has been provided for this image
  • Projection des individus
In [114]:
X_proj = pca.transform(X_scaled)
In [115]:
display_factorial_planes(X_proj,
                         [0,1],
                         pca,
                         figsize=(20,16),
                         clusters=df_acp['review_score_class'],
                         marker="o"
                        )
No description has been provided for this image

Clustering k-means¶

Nombre optimum de clusters k¶

In [116]:
# définition de k range
k_min = 2
k_max = 10
random_state = 42
In [117]:
# Distortion score with KElbow visualizer
# Instanciation du modèle de clustering et du visualizer
km = KMeans(n_init=10, init='k-means++', random_state = random_state)
visualizer = KElbowVisualizer(km, k=(k_min, k_max))

# Entraînement du visualizer et affichage du graphique
visualizer.fit(X_proj)
visualizer.show()
plt.show()

# Récupération de la valeur du coude
k_acp = visualizer.elbow_value_
No description has been provided for this image
In [118]:
# Silhouette score
silhouette_scores = []

for k in range(k_min, k_max):
    kmeans = KMeans(n_clusters=k, n_init=10, init='k-means++', random_state = random_state)
    kmeans.fit(X_proj)
    labels = kmeans.labels_
    silhouette_scores.append(silhouette_score(X_rfm_scaled, labels))

# Représentation graphique du Silhouette scor
plt.plot(range(k_min, k_max), silhouette_scores)
plt.title('Coefficient de silhouette pour chaque nombre de clusters k')
plt.xlabel('Nombre de clusters')
plt.ylabel('Coefficient de silhouette')

plt.show()
No description has been provided for this image

Clustering¶

In [119]:
# Instanciation et Entraînement du modèle k-means
kmeans = KMeans(n_clusters=k_acp,n_init=10, init='k-means++', random_state=random_state)
kmeans.fit(X_proj)

# Stockage des clusters dans la variable labels
labels = kmeans.labels_

# Stockage des centroids dans une variable
centroids_kmeans_acp = kmeans.cluster_centers_

# Nombre de clients par cluster
unique, counts = np.unique(labels+1, return_counts=True)

dict(zip(unique, counts))
Out[119]:
{1: 2242, 2: 9455, 3: 64112, 4: 16325, 5: 908}
  • Stabilité des clusters

Les tests montrent que les clusters sont stables.

In [120]:
# Tests de stabilité des clusters
for i in range(3):
    kmeans_test = KMeans(n_clusters=k_acp, n_init=10, init='k-means++')
    kmeans_test.fit(X_proj)
    labels_test = kmeans_test.labels_
    unique, counts = np.unique(labels_test + 1, return_counts=True)
    print("Test", i + 1)
    print(dict(zip(unique, counts)))
Test 1
{1: 16332, 2: 64143, 3: 2199, 4: 908, 5: 9460}
Test 2
{1: 64112, 2: 16325, 3: 908, 4: 2242, 5: 9455}
Test 3
{1: 64117, 2: 16321, 3: 908, 4: 9454, 5: 2242}

Réprésentation des clusters¶

In [121]:
# Affichage des clusters sur le premier plan factoriel de l'ACP
fig, ax = plt.subplots(1,1, figsize=(10,10))

sns.scatterplot(data=None,
                x=X_proj[:, 0],
                y=X_proj[:, 1],
                hue=labels+1,
                palette=my_palette[:k_acp],
                ax=ax)# Affichage des individus
sns.scatterplot(data=None,
                x=centroids_kmeans_acp[:, 0],
                y=centroids_kmeans_acp[:, 1],
                marker="s",
                c="black",
                ax=ax)# Affichage des centroïdes

ax.set_xlabel("PC1")
ax.set_ylabel("PC2")
ax.set_title("K-means : Projection des clusters et des centroïdes sur le premier plan factoriel")
plt.xlim(min(X_proj[:,0])-1,max(X_proj[:,0])+1)
plt.ylim(min(X_proj[:,1])-1,max(X_proj[:,1])+1)
plt.show()
No description has been provided for this image
In [122]:
# Affichage des clusters sur le premier plan factoriel de l'ACP
fig, ax = plt.subplots(1,1, figsize=(10,10))

sns.scatterplot(data=None,
                x=X_proj[:, 2],
                y=X_proj[:, 3],
                hue=labels+1,
                palette=my_palette[:k_acp],
                ax=ax)# Affichage des individus
sns.scatterplot(data=None,
                x=centroids_kmeans_acp[:, 2],
                y=centroids_kmeans_acp[:, 3],
                marker="s",
                c="black",
                ax=ax)# Affichage des centroïdes

ax.set_xlabel("PC1")
ax.set_ylabel("PC2")
ax.set_title("K-means : Projection des clusters et des centroïdes sur le deuxième plan factoriel")
plt.xlim(min(X_proj[:,2])-1,max(X_proj[:,2])+1)
plt.ylim(min(X_proj[:,3])-1,max(X_proj[:,3])+1)
plt.show()
No description has been provided for this image

Cluster 3 - Beaucoup d'articles Cluster 2 - Grosses dépenses

In [123]:
# Dataframe avec les clusters
df_kmeans = pd.DataFrame(data=X_proj,
                         columns=['PC1','PC2','PC3','PC4','PC5'],
                         index=names)

df_kmeans.loc[:,'cluster_kmeans'] = labels+1
df_kmeans = df_kmeans.sort_values('cluster_kmeans')
df_kmeans.loc[:,'cluster_kmeans'] = df_kmeans['cluster_kmeans'].astype(str)
In [124]:
# Projection des clusters sur les 3 premières composantes de l'ACP
fig = px.scatter_3d(
    df_kmeans, x='PC1', y='PC2', z='PC3', color='cluster_kmeans',
    title=f'Clustering K-means - Total Explained Variance: {scree.iloc[2,3]:.2f}%'
)
fig.show()

Analyse des clusters¶

In [125]:
# Représentation des centroïdes sous forme de heatmap
centroids_kmeans_acp_df = pd.DataFrame(centroids_kmeans_acp,
                                   index = np.unique(labels+1),
                                   columns = ['PC1','PC2','PC3','PC4','PC5'] )

fig, ax = plt.subplots(figsize=(10, 5))
sns.heatmap(centroids_kmeans_acp_df.T, vmin=-2, vmax=2, annot=True, cmap="coolwarm", fmt="0.2f")
plt.show()
No description has been provided for this image
In [126]:
# Ajout de la colonne cluster_kmeans
centroids_kmeans_acp_df = centroids_kmeans_acp_df.reset_index()
centroids_kmeans_acp_df = centroids_kmeans_acp_df.rename(columns={'index':'cluster_kmeans'})
In [127]:
# Représentation des centroïdes sur des radar charts
plt.figure(figsize=(10, 15))

# Boucle pour afficher les radars charts
for row in range(0, len(centroids_kmeans_acp_df.index)):
    make_spider(data=centroids_kmeans_acp_df,
                group='cluster_kmeans',
                row=row,
                title='Cluster '+str(centroids_kmeans_acp_df['cluster_kmeans'][row]),
                color=my_palette[row])
No description has been provided for this image

Conclusion & Perspectives¶

Conclusion

  • RFM Marketing :
    • avantages :
      • simple à comprendre et à mettre en œuvre
      • permet d'identifier les segments de clients les plus rentables, tels que les clients les plus fidèles ou ceux qui dépensent le plus, ce qui permet aux entreprises de concentrer leurs efforts marketing et leurs ressources là où elles auront le plus d'impact.
    • inconvénients :
      • ne prend en compte que trois dimensions du comportement d'achat des clients (récence, fréquence, montant), ce qui peut ne pas suffire pour capturer tous les aspects de la relation client
      • biaisée par la définition des classes avec les quantiles
  • Clustering avec RFM :
    • Méthode retenue k-means : la taille du jeu de données ne permet pas d'utiliser CAH et DBScan
    • Les 3 variables RFM permettent une bonne interprétation des segments.
  • Clustering avec RFM + satisfaction :
    • apporte plus d'informations sur les clients et permet encore d'avoir une définition actionnable des segments
  • Plus de variables ACP + kmeans :
    • Difficulté d'interprétation des clusters avec les composantes principales. Perte de pertinence et de sens métier tangible

Perspectives

  • Réaliser une segmentation sur les évènements micros tels que les Black Friday pour analyser les profil des clients et cibler ceux qui pourraient revenir (achat de produits consommables par exemple).
In [ ]: